Remix on Cloudflare WorkersからCloudflare R2を使う
はじめに
こんにちは、CX事業本部MAD事業部の森茂です。
RemixをCloudflare Workersで動かす最初の一歩をブログ記事で紹介させていただきましたが、今回は引き続きCloudflare WorkersにデプロイしたRemixアプリケーションから先日オープンベータとしてサービスが開始されたCloudflare R2を扱う方法について紹介させていただきます。
Cloudflare R2について
Cloudflare R2はAWS S3対抗となるCloudflare社のサービスのひとつでS3互換のAPIを備えたサービスです。まだベータ版という位置づけながら利便性はもちろんコストに対しての優位性も感じられます。
項目 | 無料枠 | 金額 |
---|---|---|
外部への転送量 | --- | 無料 |
ストレージ | 10GB/月 | $0.015/GB(月額) |
Class A Operation(主に書き込みなど) | 1,000,000リクエスト/月 | $4.50/1000,000リクエスト |
Class B Operation(主に読み込みなど) | 10,000,000リクエスト/月 | $0.36/1000,000リクエスト |
*2022年5月15日現在
R2 サービスの登録
R2は従量課金制となっているため事前に支払い情報の登録が必要です。とはいえ無料枠がかなり大きいので検証範囲の範囲では無料枠を超えることは少なそうです。
R2はダッシュボードからR2有料プランを購入することですぐに利用できるようになります。(購入といってもこの時点で料金はかかりません)
R2バケットの作成
ダッシュボードから作成することもできますが、今回はCLIから作成します。
Wranglerがインストールされていない場合はインストールとアカウントの紐付けをあらかじめ行っておいてください。
Wrangler v2のインストールとアカウントの紐付け
$ npm install -g wrangler $ wrangler login
ログイン状況の確認
$ wrangler whoami
バケットの作成
今回はremix-r2-example
という名前でバケットを作成します。
$ wrangler r2 bucket create remix-r2-example ⛅️ wrangler 2.0.5 ------------------- Creating bucket remix-r2-example. Created bucket remix-r2-example.
CLIでバケットが作成できているか確認しておきます。
$ wrangler r2 bucket list [ { "name": "remix-r2-example", "creation_date": "2022-05-14T10:19:15.075Z" } ]
念の為ダッシュボードからも確認しておきます。
なお、初期状態ではPrivateモードとなり外部からはアクセスのできないバケットが作成されます。紐付けられたユーザー、バインディングしたWorkersやサービスからのみ接続が可能な状態です。
Remix on Cloudflare WorkersからR2へアクセスする
アプリケーションの用意
下記の記事を参考にベースとなるRemixアプリケーションを用意します。
Remixのテンプレートから一部パッケージを更新したボイラープレートも用意していますのでこちらもご利用ください。
$ npx create-remix@latest --template himorishige/remix-cloudflare-workers-boilerplate
バインディング
また、Workersで動くアプリケーションからR2バケットにアクセスするためにはバインディングという紐付けが必要になります。
バインディングは、WorkerがKVの名前空間、Durable Objects、R2 Bucketなどの外部リソースと相互作用する方法です。バインディングを設定することでユーザーが設定した名前空間で各リソースへの相互アクセスが可能となります。Workersで動作するアプリケーションからはwrangler.toml
ファイルを使った指定が可能です。
まず、wrangler.toml
ファイルにR2へのバインディングを追記します。なおOAuth認証を行っているCLI環境からのデプロイはaccount_id
の記載は不要です。(CI/CD環境での利用時にはaccount_id
の他にトークンの用意も必要となります)
name = "remix-cloudflare-workers" main = "./build/index.js" compatibility_date = "2022-04-05" account_id = "" workers_dev = true [site] bucket = "./public" [build] command = "npm run build" [[r2_buckets]] binding = "MY_BUCKET" bucket_name = "remix-r2-example"
wrangler.toml
ファイルの反映のためCloudflare Workersへデプロイしておきます。
$ npm run deploy
RemixからR2へのアクセス
Workers上ではバインディングしたサービスに対してグローバルにアクセスできます。そのためTypeScript環境で構築する場合は型定義ファイルを用意しておく必要があります。なおR2Bucket
はRemixインストール時にCloudflare Workersテンプレートを選択していれば最初から用意してくれている型情報です。(@cloudflare/workers-types
)
export {}; // cloudflare/workers-types // https://github.com/cloudflare/workers-types#using-bindings declare global { const MY_BUCKET: R2Bucket; }
Remixのトップページからバケットの中身を一覧で取得するように書き換えていきます。
import { json } from '@remix-run/cloudflare'; import type { LoaderFunction } from '@remix-run/cloudflare'; import { useLoaderData } from '@remix-run/react'; export const loader: LoaderFunction = async () => { // MY_BUCKETというグローバルの値にアクセスする const list = await MY_BUCKET.list(); if (!list) return null; return json(list); }; export default function Index() { const data = useLoaderData(); return ( <div> <h1>Welcome to Remix</h1> <pre>{JSON.stringify(data, null, 2)}</pre> </div> ); }
まだアプリケーションからファイルをアップロードできないので、いったんダッシュボードからR2バケットへいくつか画像ファイルをアップロードしておき、RemixをCloudflare Workersへデプロイしてみましょう。
$ npm run deploy
デプロイ成功時に表示されたURLへアクセスしてみると、バケットの中身が一覧表示されます。
なお、RemixではMiniflareというライブラリを使い開発サーバー起動時にはローカルにCloudflare Workersの環境をシミュレートしてくれます。しかしR2はまだ対応していないためローカルでシミュレートすることができません。少々手間ではありますがR2を使ったアプリケーションを構築する際はまだ都度デプロイして検証する必要がありそうです。
R2バケットへファイルをアップロードする
必要なライブラリのインストール
Remixで画像のアップロードなどmutltipart/form-data
を取り扱う場合はマルチパートをパースしてくれるunstable_parseMultipartFormDataが便利なのですが、Node.js環境に依存するため今回は利用できません。
Remix 1.5.1より`unstable_parsemultipartformdata`がBufferではなくUnit8Arrayを扱うように変更されNode.js依存がなくなりました。そのため`js-cfw-formdata-polyfill`を利用しない方法も取ることができるようになりました。
Cloudflare Workersのはまりどころとして、ServiceWorker環境で動作していること、つまりESMであり、Node.jsではないことがあげられるでしょう。Cloudflare Workersは、AWS LambdaのようにESMを実行するサーバーレス環境ではありますが、Node.jsの代わりにV8を使用しています。そのためNode.js依存のライブラリはそのまま利用できないので開発時には注意が必要です。
たとえば今回のようなアップロード機能に関わる部分としては、Cloudflare WorkersではFormDataによるファイルのパースができません。(Bufferがいないなど)
しかしながら、Node.jsでよく利用されている機能をCloudflare Workers環境で動作させるユーティリティーやライブラリ、ポリフィルが多数OSSとして公開されています。今回もFormDataをパースするポリフィルjs-cfw-formdata-polyfill
が公開されているのでそちらを利用させていただきましょう。
またあわせてuuid
やjwt
などNode.js依存のライブラリをWorkers環境で利用できるユーティリティーライブラリからuuidを生成する@cfworker/uuid
もインストールしておきます。
$ npm install @ssttevee/cfw-formdata-polyfill @cfworker/uuid
アップロードコンポーネントの実装
ファイルをアップロードするためのForm
コンポーネントとAction Function
を用意します。
import { json } from '@remix-run/cloudflare'; import type { LoaderFunction, ActionFunction } from '@remix-run/cloudflare'; import { Form, useActionData, useLoaderData, useTransition, } from '@remix-run/react'; // import parseFormData from '@ssttevee/cfw-formdata-polyfill/ponyfill'; import { useEffect, useRef } from 'react'; import invariant from 'tiny-invariant'; import { uuid } from '@cfworker/uuid'; export const action: ActionFunction = async ({ request }) => { // polyfillを使ってFormDataをパースする(Remix v1.5.1より不要) // const form = await parseFormData.call(request); // Remix v1.5.1からunstable_parseMultipartFormDataがWorkersでも利用できるようになったため const uploadHandler = unstable_createMemoryUploadHandler({ maxPartSize: 1024 * 1024 * 10, }); // default 3000000B(3MB) const form = await unstable_parseMultipartFormData(request, uploadHandler); // ここまで const file = form.get('file') as Blob; invariant(file, 'File is required'); // ファイル名をuuidに変換、拡張子はいったんmime-typeから流用 const fileName = `${uuid()}.${file.type.split('/')[1]}`; // R2バケットへファイルをアップロード const response = await MY_BUCKET.put(fileName, await file.arrayBuffer(), { httpMetadata: { contentType: file.type, }, }); return json( { message: `Put ${fileName} successfully!`, object: response }, { status: 200 }, ); }; export const loader: LoaderFunction = async () => { // バケットの一覧を取得 const list = await MY_BUCKET.list(); if (!list) return null; return json(list); }; export default function Index() { const data = useLoaderData(); const actionData = useActionData<{ message: string; object: R2Object }>(); // useTransitionを使いアップロードの状態を取得 const transition = useTransition(); const isUploading = !!transition.submission; const formRef = useRef<HTMLFormElement>(null); // アップロード後にファイル選択をクリアする useEffect(() => { if (!isUploading) { formRef.current?.reset(); } }, [isUploading]); return ( <div> <h1>Welcome to Remix</h1> <div> <Form replace method="post" encType="multipart/form-data" ref={formRef}> <input type="file" name="file" accept="image/*" /> <button type="submit" disabled={isUploading}> Upload </button> </Form> </div> {actionData && ( <> <p>{actionData.message}</p> <p>{actionData && JSON.stringify(actionData.object, null, 2)}</p> </> )} <pre>{JSON.stringify(data, null, 2)}</pre> </div> ); }
Form
コンポーネントやAction Function
の使い方については下記ブログ記事でも紹介していますのでよろしければこちらも参照ください。
動作の確認
ここまで実装したところで、Cloudflare Workersにデプロイして確認してみましょう。
$ npm run deploy
バケットが空の状態から、適当な画像ファイルなどをフォームからアップロードしてみましょう。
ファイルのアップロードが行われ、完了後にバケットの一覧にアップロードされたファイルが表示されるのが確認できました。
R2バケットのファイルを表示する
次はR2バケットにアップロードしたファイルを取得し、表示する部分を実装します。
PrivateモードのではR2バケットのURLを直接開いても中身を閲覧することはできません。(バインディングしたWorkersのアプリケーションを経由した場合は可能)そのため画像(ファイル)を閲覧できるためのページを用意していきます。
Remixでは画像やファイルを処理しそのまま出力するための機能としてResource Routesがあります。
OG画像の自動生成にも利用できるため下記の記事でも紹介しています。よろしければこちらも参照ください。
import type { LoaderFunction } from '@remix-run/cloudflare'; import { json } from '@remix-run/cloudflare'; import invariant from 'tiny-invariant'; export const loader: LoaderFunction = async ({ params }) => { // URLパラメーターを取得 const key = params.key; invariant(key, 'Key is required'); // URLパラメーターをKeyにR2オブジェクトを取得 const object = await MY_BUCKET.get(key); if (object === null) { return json({ message: 'Object not found' }, { status: 404 }); } // R2オブジェクトのメタデータからheaderを生成 const headers: HeadersInit = new Headers(); object.writeHttpMetadata(headers); headers.set('etag', object.etag); // オブジェクトを返す return new Response(object.body, { headers }); }
Cloudflare Workersにデプロイして確認してみましょう。
$ npm run deploy
https://WorkersのURL/images/R2オブジェクトのkey名
でアクセスするとファイルをブラウザに表示できます。
画像一覧のページの作成
Resource Routesを利用した画像の一覧ページも用意してみます。R2バケットのオブジェクト一覧を取得して、key名をimgタグ、Linkタグへ反映します。
import type { LoaderFunction } from '@remix-run/cloudflare'; import { json } from '@remix-run/cloudflare'; import { Link, useLoaderData } from '@remix-run/react'; export const loader: LoaderFunction = async () => { // バケットのオブジェクト一覧を取得 const objects = await MY_BUCKET.list(); if (!objects) return null; return json(objects); }; export default function () { const { objects } = useLoaderData<{ objects: R2Object[] }>(); return ( <div> <h1>R2 Bucket List</h1> <ul> {objects.map((object) => ( <li key={object.key}> <p style={{ width: '320px' }}> <Link to={`/images/${object.key}`} target="_blank" rel="noreferrer" > <img src={`/images/${object.key}`} alt="" style={{ width: '100%', height: 'auto' }} /> </Link> </p> </li> ))} </ul> </div> ); }
デプロイして確認してみましょう
$ npm run deploy
ファイルのサイズや数によっては少し表示に時間がかかるものもあるかもしれませんが、R2バケットのオブジェクトが表示されます。そもそも画像を扱うのであればCloudflareにはCloudflare Imagesというサービスがあるのでこのような用途として利用する機会は少ないかもしれませんが。。R2バケットのオブジェクト取得の例として参考にしていただければと思います。
さいごに
いかがでしょうか?今回はCloudflare WorkersにデプロイしたRemixからR2バケットを利用する実装例を紹介させていただきました。AWS S3と同様のAPIとなっているためS3を利用したアプリケーションの移行も比較的容易にできそうです。しかもaws-sdk-js
やboto3
も利用することができてしまうのも面白いところです。